# <include-predictive_regression/utils.py>
# <imports>
import pandas as pd
import plotly.io as pio
from predictive_regression import utils
pd.options.plotting.backend = "plotly"
pio.templates.default = "seaborn"
pio.base_renderers.default = "notebook_connected+vscode"
import statsmodels.api as sm
In this assignment we create a two-stage regression model to predict the future weekly "returns" of five-year credit default swaps for 12 publicly traded, large-capitalization companies. The first stage entails creating contemporaneous models of the CDS "return" and stock price return and then calculating residuals. In the second stage, we create a model that predicts the CDS "return" residual based on the prior observation's equity return residual. Our period of analysis spans from 2018-01-03 to 2021-4-30.
The real world scenario here can be described as predicting a change in bond prices based on the prior observation's change in equity price. One thing that may limit the predictive power of our models is that we are using a full week as our period of analysis. Unless the timing of the change in equity price is such that it occurs at the end of a week, such that if there is a lag to the adjustment of the bond price, it is likely to occur in the subsequent period, our model won't pick it up if it occurs in the same period as the equity price adjustment.
We model contemporaneous CDS "returns" as a function of stock price return and "return" an index of other CDS "returns", defined as the arithmetic average the CDS's of the other 11 companies in our universe. For each of these models we perform ordinary least squares regression for each ticker, for each contiguous 16 week period throughout our entire period of analysis.
$$ r_{E}^{CDS} \sim r_{E}^{Equity} + r_{Index}^{CDS} + \epsilon $$We model contemporaneous equity returns as a function of the return on the market, defined as the return on SPY.
$$ r_{E}^{Equity} \sim r_{SPY}^{Equity} + \epsilon $$To predict the contemporaneous CDS return we end up with $$ f_{E,n} = \beta_{E,n}^{Intercept} + \beta_{E,n}^{Equity} \cdot r_{E,n}^{Equity} + \beta_{E,n}^{Index} \cdot r_{E,n}^{Index} $$
$$ g_{E,n} = \gamma_{E,n}^{Intercept} + \gamma_{E,n}^{SPY} \cdot r_{SPY,n}^{Equity} $$Contemporaneous residuals can the be defined as for CDS $$ \rho_{E,n} = f_{E,n} - r_{E,n}^{CDS} $$
and
$$ c_{E,n} = g_{E,n} - r_{E,n}^{Equity} $$Our predictive model then becomes
$$ \rho_{E,n} = c_{E,n-1} + \epsilon $$where we are lagging the equity residual back one observation from the CDS residual and using the various window methodologies and lengths described above to calculate the regression coefficients. Our model then becomes
$$ h_{E,n} = \mu_{E,n}^{Intercept} + \mu_{E,n} \cdot c_{E,n-1} $$and residuals can be defined as
$$ q_{E,n} = \rho_{E,n} - h_{E,n} $$Our job is to compare the performance of the predictive regressions from the exponentially weighted windows to those of the boxcar windows. We first compare the overall r-squared across all tickers for each window methodology and length. However, in the real world, it may be the case that certain combinations of window length and methodology may perform better for certain tickers. To that end, we analyze the r-squared for each ticker for each methodology and window length to identify the combination that performs best for each ticker.
Importantly, as is highlighted in the first set of asset performance charts below, the world changed at the outset of the pandemic and developing models that fit well across any period including the pandemic is going to be challenging. In order to get sense for how much more we can improve our model, we construct an alternate set of regression coefficients stopping just before the pandemic and compare their performance to the models developed from the entire time period.
As mentioned above, we try to improve the performance of our models by reducing the period of analysis from a week to a day, to see if the shorter duration results in a stronger evidence of a lagged relationship between changes in equity prices and changes in CDS spreads.
The best combination of window methodology and length turned out to be a boxcar window with a length of 24 weeks and resulted in an overall $R^{2}$ of 0.093.
By pulling out the best window methodology-length combination for each ticker were able to increase our overall $R^2$ to 0.131.
We start by fetching the CDS and equity return data.
date_range = pd.date_range("2018-01-03", "2021-04-30", freq="7D")
df_data = utils.get_data(date_range)
df_data.tail()
| series | adj_close | r_equity | r_spread | spread5y | r_index | r_spy | |
|---|---|---|---|---|---|---|---|
| date | ticker | ||||||
| 2021-04-28 | LUV | 61.770000 | -0.004362 | -0.017298 | 0.008339 | -0.010192 | 0.003191 |
| MAR | 149.450000 | 0.031885 | -0.008381 | 0.008758 | -0.011003 | 0.003191 | |
| T | 30.960000 | 0.027839 | -0.023042 | 0.006872 | -0.009670 | 0.003191 | |
| WFC | 44.983531 | 0.041673 | -0.031137 | 0.005364 | -0.008934 | 0.003191 | |
| XOM | 58.110000 | 0.036986 | 0.009787 | 0.003850 | -0.012655 | 0.003191 |
Let's take a look at the returns that we ultimately are going to be predicting and their underlying levels to see how tough our job is going to be. This is again, our familiar pattern with conspicuous precipitous decline in asset values at the onset of the pandemic, followed by a period of increased volatiilty and then a resumption of more "normal" behavior in recent times.
utils.make_lines_chart(df_data.spread5y)
utils.make_lines_chart(
df_data.r_spread.groupby("ticker").cumsum(),
'Cumulative Weekly "Return" on 5-Year CDS Spread'
)
utils.make_lines_chart(
df_data.r_equity.groupby("ticker").cumsum(),
'Cumulative Weekly Equity Return'
)
As a first pass, let's take a look at the correlation of weekly equity price returns and returns on CDS spreads at lags of 0, one and two weeks. This shows that the corelation in the same week is over 50%, but a good portion of that is likely forward looking. Interestingly, after that, at lags from one to four weeks, the correlation is quite similar. That could indicate that the lag relationship is most often ocurring within the week as discussed above, or there is a more muted lag effect that can play out over a longer period of time. Based on the apparent similarity of movements in the two charts above, my initial hypothesis is that the prices actually adjust more quickly and that by using the relatively long period of a week, we are missing it.
corrs = [df_data.r_spread.corr(df_data.r_equity.shift(w)) for w in range(10)]
utils.px.bar(
x=list(range(10)),
y=corrs,
title="Correlation of Equity Returns with Changes in CDS Spread by Lag Weeks",
labels=dict(y="correlation", x="weeks lag")
)
There are undoudtedly many instances where the changes happen on the same day and simply creating models with no lag with a full week as the period of analysis would be using future data to make predictions of the future. However, it would be interesting to see if the correlation increases if we decreas the period of analysis from a week to a day.
Here we do see that the correlation with a one day period as oppossed to a one week period does have a higher correlation (0.3962 vs 0.3461).
df_daily_data = utils.get_daily_data()
corrs = [df_daily_data.r_spread.corr(df_daily_data.r_equity.shift(w)) for w in range(10)]
utils.px.bar(
x=list(range(10)),
y=corrs,
title="Correlation of Equity Returns with Changes in CDS Spread by Lag Day",
labels=dict(y="correlation", x="weeks lag")
)
Here we perform our contemporaneous regressions and calculate residuals for both equity and cds returns. Both use boxcar windows of 16 weeks, but a slightly higher total $R^{2}$ was achieved by running the cds model as a robust linear model using Tukey Bisquare weights.
df_resid = utils.get_contemp_resids(df_data)
df_resid.head()
/home/caleb/.local/share/virtualenvs/predictive_regression-7OMi-ASR/lib/python3.8/site-packages/scipy/stats/stats.py:1603: UserWarning: kurtosistest only valid for n>=20 ... continuing anyway, n=16
| cds_resid | eq_resid | ||
|---|---|---|---|
| date | ticker | ||
| 2018-05-09 | BA | -0.041016 | 0.064893 |
| C | 0.035478 | 0.015434 | |
| DD | -0.016231 | -0.006366 | |
| F | -0.067737 | -0.015277 | |
| GE | -0.052186 | -0.024766 |
A quick look at the moments for each set of residuals reveals the following characteristics:
df_stats = df_resid.describe()
df_stats.loc["kurtosis"] = df_resid.kurtosis()
df_stats.loc["skewness"] = df_resid.skew()
df_stats
| cds_resid | eq_resid | |
|---|---|---|
| count | 1872.000000 | 1872.000000 |
| mean | -0.001374 | 0.000793 |
| std | 0.085407 | 0.046581 |
| min | -1.015630 | -0.365794 |
| 25% | -0.026964 | -0.018887 |
| 50% | -0.000057 | 0.000882 |
| 75% | 0.024526 | 0.022092 |
| max | 0.954388 | 0.299017 |
| kurtosis | 31.960394 | 7.192748 |
| skewness | -0.938949 | 0.092354 |
fig = utils.make_overview_chart(df_resid.cds_resid, title="CDS Returns Residual")
fig.show()
fig = utils.make_overview_chart(df_resid.eq_resid, title="Equity Returns Residual")
fig.show()
df_resid.cds_resid.corr(df_resid.eq_resid.shift())
-0.02479475475232577
Here we plot the cds residuals agains the prior week's equity residuals and see very little relationship. The fitted line is a simple OLS regression based on the single trailing equity return. The correlation is 0.0987 with an $R^{2}$ of just 0.005. it will interesting to see if this can be improved using discounted least squares, effectively taking into account a greater number of historical observations.
utils.make_residual_scatter(df_resid.copy())
Here we calculate coefficients for each equity for each observation for each window type for each window length. We do this by using the rolling covariance matrix function in pandas to get the covariance and variance for each ticker and then calculate coefficients directly as $\mu_{E,n} = \frac{\mathrm{Cov}(c_{E,n-1},\rho_{E,n})}{\mathrm{Var}(c_{E,n-1})}$.
df_resid_pred = utils.get_predicted_resids(df_resid)
df_resid_pred.dropna()
| win_type | boxcar | ... | exp_wm | |||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| stat | beta_0 | ... | var_x | |||||||||||||||||||
| win_length | t_06 | t_08 | t_10 | t_12 | t_14 | t_16 | t_18 | t_20 | t_22 | t_24 | ... | t_28 | t_30 | t_32 | t_34 | t_36 | t_38 | t_40 | t_42 | t_44 | t_46 | |
| date | ticker | |||||||||||||||||||||
| 2018-05-30 | BA | 0.006141 | 0.006141 | 0.006141 | 0.006141 | 0.006141 | 0.006141 | 0.006141 | 0.006141 | 0.006141 | 0.006141 | ... | 0.002669 | 0.002673 | 0.002676 | 0.002679 | 0.002682 | 0.002684 | 0.002686 | 0.002688 | 0.002690 | 0.002692 |
| C | 0.029601 | 0.029601 | 0.029601 | 0.029601 | 0.029601 | 0.029601 | 0.029601 | 0.029601 | 0.029601 | 0.029601 | ... | 0.001014 | 0.001014 | 0.001015 | 0.001015 | 0.001015 | 0.001016 | 0.001016 | 0.001016 | 0.001016 | 0.001016 | |
| DD | -0.024496 | -0.024496 | -0.024496 | -0.024496 | -0.024496 | -0.024496 | -0.024496 | -0.024496 | -0.024496 | -0.024496 | ... | 0.000261 | 0.000260 | 0.000259 | 0.000259 | 0.000258 | 0.000258 | 0.000257 | 0.000257 | 0.000257 | 0.000256 | |
| F | 0.022161 | 0.022161 | 0.022161 | 0.022161 | 0.022161 | 0.022161 | 0.022161 | 0.022161 | 0.022161 | 0.022161 | ... | 0.000309 | 0.000309 | 0.000309 | 0.000309 | 0.000309 | 0.000309 | 0.000309 | 0.000309 | 0.000309 | 0.000309 | |
| GE | 0.003240 | 0.003240 | 0.003240 | 0.003240 | 0.003240 | 0.003240 | 0.003240 | 0.003240 | 0.003240 | 0.003240 | ... | 0.002231 | 0.002226 | 0.002221 | 0.002217 | 0.002214 | 0.002210 | 0.002207 | 0.002205 | 0.002202 | 0.002200 | |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 2021-04-28 | LUV | -0.009270 | 0.001792 | 0.012596 | 0.008640 | 0.004234 | 0.000992 | -0.002661 | 0.000372 | 0.004644 | 0.000318 | ... | 0.003071 | 0.003108 | 0.003136 | 0.003157 | 0.003173 | 0.003184 | 0.003191 | 0.003195 | 0.003196 | 0.003195 |
| MAR | 0.014787 | 0.013750 | 0.016505 | 0.015093 | 0.012262 | 0.011150 | 0.009140 | 0.007880 | 0.012551 | 0.011518 | ... | 0.003623 | 0.003639 | 0.003648 | 0.003652 | 0.003651 | 0.003647 | 0.003640 | 0.003630 | 0.003619 | 0.003607 | |
| T | 0.005636 | 0.004291 | 0.000630 | -0.006510 | -0.006003 | -0.001747 | -0.000453 | -0.001386 | -0.001042 | -0.001916 | ... | 0.000900 | 0.000916 | 0.000929 | 0.000940 | 0.000950 | 0.000958 | 0.000965 | 0.000971 | 0.000976 | 0.000980 | |
| WFC | -0.009776 | -0.007056 | -0.007828 | -0.005683 | -0.004721 | -0.004560 | -0.004387 | -0.005687 | -0.003363 | 0.001380 | ... | 0.002128 | 0.002136 | 0.002139 | 0.002139 | 0.002136 | 0.002130 | 0.002124 | 0.002116 | 0.002107 | 0.002097 | |
| XOM | -0.004763 | -0.003148 | -0.003993 | -0.001474 | 0.003838 | 0.009724 | 0.013133 | 0.016393 | 0.008234 | 0.005854 | ... | 0.002239 | 0.002189 | 0.002142 | 0.002098 | 0.002056 | 0.002017 | 0.001980 | 0.001945 | 0.001912 | 0.001881 | |
1836 rows × 252 columns
Finally, we calculate the total $R^{2}$ for each combination of window type and window length. Interstingly the boxcar windows type with a length between 24 and 48 weeks appears to have the best fit (boxcar window lengths are equal to 2 x the exponentially weighted $1/\lambda$).
For reference, a halflife of 12 is equivalent to $t = 8$ (where $t = 1 / \alpha$ and $\alpha = 1 - e^{-ln(2) / halflife}$ and a boxcar window of 16, given our convention for converting $\lambda$ to boxcar window length. At that point of comparison, the boxcar window of length 16 performs better than the exponentially weighted window with a halflife of 12.
fig = utils.make_rsq_comparison(df_resid_pred)
fig.show()
Below we select the highest $R^2$ combination of window type and window length for each ticker. There are actually differences in the fits between the tickers. BA, for example, appears to do relatively better with the exponentially weighted windows. It's max $R^2$ is 0.10 with the exponentially weighted window with $t=12$. MAR, on the other hand, seems to do best with the boxcar window of 12 days (t=6) with an $R^2$ of 0.234.
df_ticker_rsq = utils.get_ticker_rsq(df_resid_pred)
fig = utils.make_rsq_ticker_comparison(df_ticker_rsq)
fig.show()
Here we pull out the best r-sqaured for each ticker. It looks like most are of the boxcar variety and and in the range of 24 to 48 weeks.
max_rsqs = []
for s in df_ticker_rsq.columns.levels[0]:
w = df_ticker_rsq[s].max().idxmax()
t = df_ticker_rsq[s][w].idxmax()
max_rsqs.append((s, w, t, df_ticker_rsq[s][w].max()))
df_max_rsq = pd.DataFrame(max_rsqs)
df_max_rsq.columns = ["ticker", "win_type", "win_length", "r-sq."]
df_max_rsq
| ticker | win_type | win_length | r-sq. | |
|---|---|---|---|---|
| 0 | BA | exp_wm | t_10 | 0.240549 |
| 1 | C | exp_wm | t_46 | -0.005902 |
| 2 | DD | boxcar | t_40 | 0.010604 |
| 3 | F | exp_wm | t_08 | 0.301011 |
| 4 | GE | boxcar | t_38 | -0.004328 |
| 5 | JPM | exp_wm | t_46 | 0.039032 |
| 6 | LOW | boxcar | t_10 | 0.107542 |
| 7 | LUV | boxcar | t_06 | 0.240101 |
| 8 | MAR | boxcar | t_30 | 0.097636 |
| 9 | T | boxcar | t_36 | -0.016100 |
| 10 | WFC | boxcar | t_12 | 0.162899 |
| 11 | XOM | boxcar | t_12 | 0.005127 |
For our final analysis, let's see how much we can improve the overall $R^2$ by using the best window specification for each ticker. And we see that we are able to increase our overall $R^2$ from 0.093 to 0.131.
best_resids = []
for best in df_max_rsq.iterrows():
best_resids.append(df_resid_pred.loc[df_resid_pred.index.get_level_values("ticker") == best[1].ticker][[(best[1].win_type, "resid_sq", best[1].win_length), (best[1].win_type, "error_sq", best[1].win_length)]].unstack("ticker"))
df_best_rsq = pd.concat(best_resids, axis=1).sort_index(axis=1).stack("stat").reset_index().groupby("stat").sum().sum(axis=1)
1 - df_best_rsq.resid_sq / df_best_rsq.error_sq
/home/caleb/.local/share/virtualenvs/predictive_regression-7OMi-ASR/lib/python3.8/site-packages/pandas/core/generic.py:4153: PerformanceWarning: dropping on a non-lexsorted multi-index without a level parameter may impact performance.
0.13091761528714907